<?php
/*
 * xml_assoc.php
 * 
 * http://code.google.com/p/xml2assoc/
 *
 * It defines the classes Xml2Assoc(*), Assoc2Xml and Obj2Xml (see documentation
 * on the head of each class).
 * It depends on XMLReader and XMLWriter
 *
 * Copyright (C) 2010  Alvaro Villalba <villalba.it@gmail.com>
 *
 *  This program is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation, either version 3 of the License, or
 *  any later version.
 *
 *  This program is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
 *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
 *
 * (*) Xml2Assoc based on the software:
 * http://www.php.net/manual/en/class.xmlreader.php#87288
 *
 * Tip: All about XML, http://www.w3.org/TR/xml/
 *
 */

/**
 * Xml2Assoc, obtains an associative array from a XML document
 */
class Xml2Assoc {

	protected $_xml;
	protected $_xmlContent;
	protected $_eol;
	protected $_tab;

	/**
	 * constructor, creates a XMLReader either from a stream or from a string
	 * 
	 * @param string $sXml, either a URL or a XML-string
	 */
	public function __construct( $sXml = '' ) {

		if( strlen($sXml) == 0 )
			$this->_exception( 'xml data is empty' );

		$this->_xmlContent = ''; // contain the parsed xml as it was read

		// string or stream?
		if( preg_match( '/^<\?xml/', trim($sXml) ) ) {
			/* to parse XML from a string */
			try {
				$this->_xml = new XMLReader();
				$this->_xml->XML($sXml);
			} catch ( Exception $e ) {
				$this->_exception( $e->getMessage() );
			}
		} else {
			/* to parse XML from a stream */
			try {
				$this->_xml = new XMLReader();
				$this->_xml->open($sXml); // requires allow_url_fopen = On
			} catch ( Exception $e ) {
				$this->_exception($e->getMessage() . ' | Try to open: '.$sXml);
			}
		}
	}

	/**
	 * xml_read, is the parser. It returns an array of the form:
	 *  $elements = array(
	 *      [i] => array (
	 *          'tagName' => <xml-element name>,
	 *          'content' => <xml-element content>,
	 *          'attributes' => array(
	 *              [j] => array(
	 *                  'attribName' => <attribute name>,
	 *                  'attribValue' => <attribute value>
	 *              )
	 *          ),
	 *          'elements' => array( <recursion: $elements again> )
	 *      )
	 *  );
	 *  where i, j are integers starting at 0, or null if error.
	 *
	 * @return array
	 */
	public function read() {

		$xml = $this->_xml;
		$elements = array();
		$eleDepth = 0;

		$elementsUp[$eleDepth] = &$elements;

		while( $xml->read() ) {
			switch ($xml->nodeType) {

				case XMLReader::END_ELEMENT:

					if( ($xml->depth - 1) < $eleDepth ) {
						$elements = &$elementsUp[$eleDepth];
						$ele = &$elements[(sizeof($elements) - 1)];
						$eleDepth = ($xml->depth - 1);
					}
					break;

				case XMLReader::ATTRIBUTE:

					// adding attribute. See attributes below:
					// read does not go through attributes :(
/*					 if( ! is_array($ele['attributes']) ) {
						// critical error
						echo "Critical Error<br>\n";
					}
					$ele['attributes'][] = array($xml->name => $xml->value); */
					break;

				case XMLReader::ELEMENT:

					// New Element.
					if( strlen($xml->name) == 0 ) {
						// critical error
						$this->_exception('XML node name is null');
					}
					// xmlContent
					if( strlen($this->_xmlContent) == 0 )
						$this->_xmlContent = $xml->readOuterXML();

					// into subelements
					if( $xml->depth > $eleDepth ) {
						$eleDepth = $xml->depth;
						$elementsUp[$eleDepth] = &$elements;
						if( ! isset($ele['elements']) )
							$ele['elements'] = array();
						$elements = &$ele['elements'];
					}

					$elements[] = array( 
						'tag' => $xml->name
//						'content' => '',
//						'attributes' => array(),
//						'elements' => array()
					);
					// the working element
					$ele = &$elements[(sizeof($elements) - 1)];
					
					// its attributes
					if($xml->hasAttributes) {
						
						$xml->moveToFirstAttribute();
						$ele['attributes'] = array();
						$ele['attributes'][] = array(
							'attrName' => $xml->name,
							'attrValue' => $xml->value );
						while( $xml->moveToNextAttribute() ) {
							$ele['attributes'][] = array(
								'attrName' => $xml->name,
								'attrValue' => $xml->value );
						}
//						$xml->moveToElement();
					}
					// exception: empty element. Go up again
					if( $xml->isEmptyElement ) {
						if( ($xml->depth - 1) < $eleDepth ) {
							$elements = &$elementsUp[$eleDepth];
							$ele = &$elements[(sizeof($elements) - 1)];
							$eleDepth = ($xml->depth - 1);
						}
					}
					break;

				case XMLReader::TEXT:
				case XMLReader::CDATA:

					// content
					$ele['content'] .= $xml->value;

					break;
			}
		}

		$xml->close();
		return $elements;
	}

	/**
	 * get, class getter.
	 *
	 * @param mixed $var, method attribute
	 * @return mixed
	 */
	public function get($var)
	{
		$vars = get_class_vars(get_class($this));
		if( ! array_key_exists($var, $vars) )
			throw new Exception(
				$var . ' is not a member of ' . get_class($this));

		return $this->$var;
	}

	/* exception, writes error message and die */
	protected function _exception($msg)
	{
		throw new Exception($msg);
	}
}

/*
 * Assoc2Xml, is the opposite of Xml2Assoc: It builds a xml string from an
 *   associatie array.
 */
class Assoc2Xml {

	protected $_assoc;
	protected $_eol;
	protected $_tab;

	private $_xml;
	private $_uri; // 
	private $_depth;

	/**
	 * constructor, creates a XMLWriter and set up where to write
	 *
	 * @param array &$assoc
	 * @param string $uri, URI or either '' to write to memory
	 */
	public function __construct($assoc = array(), $uri = '') {

		if( ! is_array($assoc) )
			$this->_exception('WRONG_ASSOC');

		$this->_depth = 0;

		// set up to '' if you do not want to indent resulting XML
		$this->_eol = "\n";
		$this->_tab = "  ";

		$this->_assoc = $assoc;
		try {
			$this->_xml = new XMLWriter;
		} catch( Exception $e ) {
			$this->_exception($e->getMessage());
		}
		
		if( count($this->_assoc) != 1 )
			$this->_exception("Assoc must have only 1 item which surrounds all "
				. "nodes in 'elements'");

		$this->writeTo($uri);
	}

	/**
	 * writeTo, set up where to write, URI or memory
	 *
	 * @param string $uri, a URI or '' to write to memory
	 * @return Assoc2Xml $this
	 */
	public function writeTo( $uri = '' ) {
		try {
			if( strlen($uri) == 0 ) {
				$this->_uri = '';
				$this->_xml->openMemory();
			} else {
				$this->_uri = $uri;
				$this->_xml->openURI($this->_uri);
			}
		} catch( Exception $e ) {
			$this->_exception($e->getMessage());
		}
		return true;
	}

	/**
	 * xml_write, is the parser. It writes the XML obtained from the assoc.
	 *
	 * NOTE: maybe you want to send the header("Content-type: text/xml")
	 *   where you use xml_write
	 *
	 * @param array &$assoc, (optional. used in the recursion) assoc to be parsed
	 * @return int|string, depending on where it'll be written it returns
	 *   the number of bytes written to file or the XML string. It returns
	 *   false if error (notice that it may return 0 without error).
	 */
	public function write( &$assoc = null ) {

		$xml = $this->_xml;

		// starts document
		if( $this->_depth == 0 ) {
			$assoc = &$this->_assoc;
			$xml->startDocument('1.0', 'UTF-8');
		}
		
		$xml->text( $this->_eol );
		
		foreach( $assoc as $ele ) {
			try {
				if( ! is_array($ele) )
					$this->_exception('WRONG_ASSOC');
				if( ! isset($ele['tag']) )
					$this->_exception('WRONG_ASSOC');
					
				if( strlen($ele['tag']) == 0 ) continue;
				
				// element start
				for( $i = 1; $i < ($this->_depth + 1); $i++ ) {
					$xml->text( $this->_tab );
				}
				$xml->startElement( $ele['tag'] );

				// attributes
				if( isset($ele['attributes']) ) {
					foreach( $ele['attributes'] as $att ) {
						if( isset($att['attrName']) )
							$xml->writeAttribute(
								$att['attrName'], $att['attrValue'] );
					}
				}

				// content
				if( isset($ele['content']) && strlen($ele['content']) > 0 )
					$xml->text( $ele['content'] );

			} catch( Exception $e ) {
				$this->_exception($e->getMessage());
			}

			// subelements
			if( sizeof($ele['elements']) > 0 ) {
				$this->_depth++;
				if( $this->write( $ele['elements'] ) == false )
					return false;
			}
			
			// element end
			try {
				if( sizeof($ele['elements']) > 0 ) {
					for( $i = 1; $i < ($this->_depth + 1); $i++ ) {
						$xml->text( $this->_tab );
					}
				}
				$xml->endElement();
				$xml->text( $this->_eol );
			} catch( Exception $e ) {
				$this->_exception($e->getMessage());
			}
		}

		// flush only at the end of recursion
		if( $this->_depth == 0 ) {
			$xml->endElement();
			$xml->endDocument();
			return $xml->flush();
		} else {
			$this->_depth--;
			return true;
		}
	}

	/**
	 * get, class getter.
	 *
	 * @param mixed $var, method attribute
	 * @return mixed
	 */
	public function get($var)
	{
		$vars = get_class_vars(get_class($this));
		if( ! array_key_exists($var, $vars) )
			throw new Exception(
				$var . ' is not a member of ' . get_class($this));

		return $this->$var;
	}

	/* exception, writes error message and die */
	protected function _exception($msg)
	{
		switch( $msg ) {
			case 'WRONG_ASSOC':
				$msg = 'Expected an assoc of the form:
array(
    array(
        \'tag\' => \'tag_name\',
        \'attributes\' => array(
            array(\'attrName\' => \'attr1\', \'attrValue\' => \'value1\'),
            array(\'attrName\' => \'attr2\', \'attrValue\' => \'value2\'),
        ),
        \'content\' => \'tag content\'
        \'elements\' => RECURSION

    )
    ...
)';
				break;
		}
		throw new Exception($msg);
	}
}

/**
 * Obj2Xml,
 *
 * is similar to Assoc2Xml: It builds a xml string from the public elements of
 *   an object.
 */
class Obj2Xml extends Assoc2Xml {

	/**
	 * constructor,
	 *
	 * @see Assoc2Xml::__construct
	 */
	public function __construct($obj = array(), $uri = '') {       

		if( ! (is_object($obj) || is_array($obj)) )
			$this->_exception('WRONG_OBJ');
		
		if( count($this->_assoc) != 1 )
			$this->_exception("Assoc must have only 1 item which surrounds all "
				. "nodes in 'elements'");

		// better obj name instead of get_class($obj)
		$assoc = $this->r_assoc_get( $obj, get_class($obj) . '_' );
		
		parent::__construct($assoc, $uri);
	}

	/* generates assoc recursive */
	private function r_assoc_get( $arrObj, $tagPrefix = '' ) {
		if( is_scalar($arrObj) )
			return $arrObj;
		$assoc = array();
		foreach( $arrObj as $key => $value ) {
			if( is_scalar($value) ) {
				$assoc[] = array(
					'tag' => $key,
					'content' => $value,
					'attributes' => array(),
					'elements' => array() );
			} elseif( is_array($value) || is_object($value) ) {
				$tag = $key;
				if( is_numeric($key) )
					$tag = $tagPrefix . $tag;
				$assoc[] = array(
					'tag' => $tag,
					'content' => '',
					'attributes' => array(),
					'elements' => $this->r_assoc_get($value, $key.'_') );
			}
		}
		return $assoc;
	}
	
}

?>
